<template>
  <div
    ref="inputAtRef"
    class="input-at"
    :class="{ 'is-editor': editable }"
    :style="inputStyle"
    :contenteditable="editable"
    @input="onInput"
  ></div>
</template>

<script setup>
import { computed, nextTick, onMounted, onUnmounted, ref, watch } from 'vue'

const props = defineProps({
  placeholder: {
    type: String,
    default: '请输入内容'
  },
  minHeight: {
    type: String,
    default: 'auto'
  },
  maxHeight: {
    type: String,
    default: 'auto'
  },
  height: {
    type: String,
    default: 'auto'
  },
  // 最大字数限制，maxLength <= 0 则表示不限制
  maxLength: {
    type: [Number, String],
    default: -1
  },
  // 是否监听键盘输入@，关联at事件
  watchAt: {
    type: Boolean,
    default: true
  },
  // 是否自动滚动到内容结尾，默认开启
  autoScrollBottom: {
    type: Boolean,
    default: true
  }
})

// 双向绑定 html 富文本内容
const htmlValue = defineModel('html', {
  type: String,
  default: ''
})
// 双向绑定 editable 可写/只读
const editable = defineModel('editable', {
  type: [Boolean, String],
  default: true // true:可写 | false:只读 | plaintext-only:只粘贴纯文本
})

const emits = defineEmits(['input', 'at', 'atLink', 'overmax'])

// dom示例
const inputAtRef = ref()

const inputStyle = computed(() => {
  return {
    '--at-placeholder-text': `"${props.placeholder}"`,
    '--at-min-height': props.minHeight,
    '--at-max-height': props.maxHeight,
    '--at-height': props.height
  }
})

// 监听 htmlValue 变化同步到 DOM
watch(
  htmlValue,
  (newVal) => {
    nextTick(() => {
      if (newVal) {
        // 初始化内容，必须判断 inputAtRef.value.innerHTML !== newVal
        if (inputAtRef.value && inputAtRef.value.innerHTML !== newVal) {
          initContent(newVal)
          // 光标聚焦至最后
          cursorToEnd()
        }
      } else {
        // 清空内容
        inputAtRef.value.innerHTML = ''
      }
    })
  },
  { immediate: true }
)

onMounted(() => {
  // 监听光标变化事件
  document.addEventListener('selectionchange', getCurrentCursor)
})

onUnmounted(() => {
  // 卸载光标变化事件
  document.removeEventListener('selectionchange', getCurrentCursor)
})

// 聚焦
function focus() {
  inputAtRef.value.focus()
}

// 失焦
function blur() {
  // 必须延时，否则不会触发selectionchange事件，进而导致@时出现光标问题
  setTimeout(() => {
    inputAtRef.value.blur()
  })
}

// 判断是否失焦
function isBlur() {
  return !document.activeElement.isEqualNode(inputAtRef.value)
}

// 光标相关
const anchorNode = ref()
const anchorOffset = ref()
const focusNode = ref()
const focusOffset = ref()

// 获取当前光标节点和位置
function getCurrentCursor() {
  if (isBlur()) return // 如果失焦，则不获取
  // 获取光标
  const selection = window.getSelection()
  anchorNode.value = selection.anchorNode
  anchorOffset.value = selection.anchorOffset
  focusNode.value = selection.focusNode
  focusOffset.value = selection.focusOffset
  // console.log('selection ==> ', selection)
}

/**
 * 获取当前光标位置
 * @param {Function} cb 获取光标位置后执行的回调
 */
function cursorToNow(cb) {
  if (isBlur()) focus() // 如果失焦，则聚焦

  const selection = window.getSelection()
  // const range = selection.getRangeAt(0) // 弃用
  const range = document.createRange()

  if (anchorNode.value) {
    // console.log(anchorNode.value, anchorOffset.value)
    range.setStart(anchorNode.value, anchorOffset.value)
    range.setEnd(focusNode.value, focusOffset.value)
  } else {
    range.selectNodeContents(inputAtRef.value)
  }

  // 执行回调
  if (cb) {
    cb(range, selection)
  }

  range.collapse(false)
  selection.removeAllRanges()
  selection.addRange(range)
}

// 光标移至最后
function cursorToEnd() {
  if (isBlur()) focus() // 如果失焦，则聚焦

  requestAnimationFrame(() => {
    const range = document.createRange()
    const selection = window.getSelection()
    range.selectNodeContents(inputAtRef.value)
    range.collapse(false)
    selection.removeAllRanges()
    selection.addRange(range)

    // 滚动到内容结尾，只在可写模式，及 autoScrollBottom 开启后执行
    if (props.autoScrollBottom && editable.value) {
      scrollToBottom()
    }
  })
}

// 滚动到内容结尾
function scrollToBottom() {
  inputAtRef.value.scrollTop = inputAtRef.value.scrollHeight
}

// 输入事件回调
function onInput(e) {
  // 监听输入@
  if (props.watchAt && e.data === '@') {
    emits('at')
  }
  // 文本内容
  const text = inputAtRef.value.innerText || ''
  // 富文本内容
  const html = inputAtRef.value.innerHTML || ''
  // 最大字数限制处理
  const textStr = text.replace(/[ \t\r\n]/g, '') // 去除制表符
  const textLength = textStr.length // 当前纯文本字数
  // 超过最大字数限制，不允许再输入
  if (textLength > parseInt(props.maxLength)) {
    // 锁定内容
    initContent(htmlValue.value)
    cursorToEnd()
    emits('overmax')
    return
  }
  // 正常输入
  htmlValue.value = html // 同步到双向绑定
  emits('input', { html, text, length: textLength })
}

/**
 * 获取当前最新内容
 * @returns {String} 富文本字符串
 */
function getContent() {
  return inputAtRef.value.innerHTML
}

/**
 * 初始化内容
 * @param {String} htmlString 富文本字符串
 * @param {Boolean} triggerInput 是否主动触发输入事件，默认为 true
 */
function initContent(htmlString) {
  nextTick(() => {
    const template = document.createElement('template')
    template.innerHTML = htmlString
    const fragment = template.content
    // 先清空
    inputAtRef.value.innerHTML = ''
    // 再初始化内容节点
    inputAtRef.value.appendChild(fragment)

    // 只读模式需要给atlink添加事件
    if (!editable.value) {
      atLinkEventHandler(editable.value)
    }
  })
}

// 清空内容
function clearContent() {
  // 走监听事件，执行清空，而非直接使 dom.innerHTML = ''
  htmlValue.value = ''
}

/**
 * 插入富文本
 * @param {String} htmlString 富文本字符串
 * @param {Boolean} triggerInput 是否主动触发输入事件，默认为 true
 */
function insertHtml(htmlString, triggerInput = true) {
  // 在当前光标位置插入富文本
  cursorToNow((range) => {
    const template = document.createElement('template')
    template.innerHTML = htmlString
    const htmlNode = template.content
    // 在光标处之后插入节点
    range.insertNode(htmlNode)
  })

  // 主动触发输入事件
  if (triggerInput) {
    inputAtRef.value?.dispatchEvent(new Event('input', { bubbles: true }))
  }
}

/**
 * 插入富文本 (使用 execCommand 指令)
 * @description 仅可写模式有效
 * @param {String} htmlString 富文本字符串
 * @param {Boolean} triggerInput 是否主动触发输入事件，默认为 true
 */
function insertHtmlCmd(htmlString, triggerInput = true) {
  // 在当前光标位置插入富文本
  cursorToNow(() => {
    requestAnimationFrame(() => {
      document.execCommand('insertHtml', false, htmlString)
    })
  })

  // 主动触发输入事件
  if (triggerInput) {
    inputAtRef.value?.dispatchEvent(new Event('input', { bubbles: true }))
  }
}

/**
 * 插入文本
 * @param {String} text 文本
 * @param {Boolean} triggerInput 是否主动触发输入事件，默认为 true
 */
function insertText(text, triggerInput = true) {
  // 在当前光标位置插入文本
  cursorToNow((range) => {
    const textNode = document.createTextNode(text)
    // 在光标处之后插入节点
    range.insertNode(textNode)
  })

  // 主动触发输入事件
  if (triggerInput) {
    inputAtRef.value?.dispatchEvent(new Event('input', { bubbles: true }))
  }
}

/**
 * 插入文本 (使用 execCommand 指令)
 * @description 仅可写模式有效
 * @param {String} text 文本
 * @param {Boolean} triggerInput 是否主动触发输入事件，默认为 true
 */
function insertTextCmd(text, triggerInput = true) {
  // 在当前光标位置插入文本
  cursorToNow(() => {
    requestAnimationFrame(() => {
      document.execCommand('insertText', false, text)
    })
  })

  // 主动触发输入事件
  if (triggerInput) {
    inputAtRef.value?.dispatchEvent(new Event('input', { bubbles: true }))
  }
}

/**
 * 插入艾特
 * @param {Object} user 被插入的用户信息
 * @property {String} user.id 用户id
 * @property {String} user.name 用户名
 * @property {any} user.other 其他属性
 * @param {Boolean} triggerInput 是否主动触发输入事件，默认为 true
 */
function insertAt(user, triggerInput = true) {
  // 在当前光标位置插入节点
  cursorToNow((range) => {
    // 如果输入的@符号，需先删除@，再追加@标签
    const text = range.startContainer.textContent
    const cursorIndex = anchorOffset.value
    if (text[cursorIndex - 1] === '@') {
      range.setStart(range.startContainer, cursorIndex - 1)
      range.deleteContents()
    }

    // 在光标处之后插入节点，前后使用空格隔开
    const textNode = document.createTextNode('\u00A0')
    range.insertNode(textNode)
    // 创建@user标签
    const htmlNode = createAtLink(user)
    range.insertNode(htmlNode)
    // 空格收尾
    range.insertNode(textNode.cloneNode())
  })

  // 主动触发输入事件
  if (triggerInput) {
    inputAtRef.value?.dispatchEvent(new Event('input', { bubbles: true }))
  }
}

/**
 * 插入艾特批量
 * @param {Array<object>} users 被插入的用户信息
 * @property {String} user.id 用户id
 * @property {String} user.name 用户名
 * @property {any} user.other 其他属性
 * @param {Boolean} triggerInput 是否主动触发输入事件，默认为 true
 */
async function insertAts(users = [], triggerInput = true) {
  // 在当前光标位置插入节点

  for (let i = 0; i < users.length; i++) {
    const user = users[i]

    cursorToNow((range) => {
      // 如果输入的@符号，需先删除@，再追加@标签
      const text = range.startContainer.textContent
      const cursorIndex = anchorOffset.value
      if (text[cursorIndex - 1] === '@') {
        range.setStart(range.startContainer, cursorIndex - 1)
        range.deleteContents()
      }

      // 在光标处之后插入节点，前后使用空格隔开
      // const textNode = document.createTextNode('\u00A0')
      // range.insertNode(textNode)
      // 创建@user标签
      const htmlNode = createAtLink(user)
      range.insertNode(htmlNode)
      // 空格收尾
      // range.insertNode(textNode.cloneNode())
    })

    await new Promise((resolve) => {
      setTimeout(() => {
        resolve()
      }, 100)
    })
  }

  // 主动触发输入事件
  if (triggerInput) {
    inputAtRef.value?.dispatchEvent(new Event('input', { bubbles: true }))
  }
}

/**
 * 删除指定字符
 * @param {String} str 要删除的指定字符
 */
function deleteString(str) {
  // 在当前光标位置插入节点
  cursorToNow((range) => {
    const text = range.startContainer.textContent
    const cursorIndex = anchorOffset.value
    if (text[cursorIndex - 1] === str) {
      range.setStart(range.startContainer, cursorIndex - 1)
      range.deleteContents()
    }
  })
}

/**
 * 退格 (使用 execCommand 指令)
 */
function backspace() {
  cursorToNow(() => {
    requestAnimationFrame(() => {
      document.execCommand('delete', false, null)
    })
  })
}

/**
 * 创建艾特节点
 * @param {Object} user 被插入的用户信息
 * @property {String} user.id 用户id
 * @property {String} user.name 用户名
 */
function createAtLink(user) {
  const btn = document.createElement('span')
  btn.style.display = 'inline-block'
  btn.dataset.user = JSON.stringify(user)
  btn.className = 'at-link'
  btn.contentEditable = 'false'
  btn.textContent = `@${user.name}`
  btn.style.color = 'blue'

  return btn
}

// at-link 点击处理（闭包）
function tapAtLink(data) {
  return function () {
    emits('atLink', data)
  }
}

/**
 * atlink 事件管理
 * @param {Object} caneditable 是否可写
 */
function atLinkEventHandler(caneditable) {
  nextTick(() => {
    const atLinks = inputAtRef.value.querySelectorAll('.at-link')
    atLinks.forEach((item) => {
      const userInfo = JSON.parse(item.dataset.user)
      if (caneditable) {
        // 可写时清理事件
        if (item?._handler) {
          item.removeEventListener('click', item._handler)
          delete item._handler // 清理引用
        }
      } else {
        // 只读时绑定事件
        item._handler = tapAtLink(userInfo)
        item.addEventListener('click', item._handler)
      }
    })
  })
}

/**
 * 获取当前所有艾特
 * @description 获取当前所有艾特，并根据id去重
 * @returns {Array} 艾特数组
 */
function getAllAtLink() {
  const atLinks = Array.from(inputAtRef.value.querySelectorAll('.at-link'))
  const atList = atLinks.map((item) => JSON.parse(item.dataset.user))
  const map = new Map() // 利用 Map 去重，时间复杂度 O(n)
  const uniqueAtList = atList.filter((item) => {
    return !map.has(item.id) && map.set(item.id, true)
  })
  return uniqueAtList
}

// 监听可写
watch(
  () => editable.value,
  (newVal) => {
    // 处理 at-link 事件
    atLinkEventHandler(newVal)
  },
  {
    immediate: true
  }
)

/**
 * 给节点设置inputmode属性来控制键盘是否弹出
 * @description 设置none时将会阻止键盘弹出，设置remove将会恢复
 * @param {String} type none | remove
 */
function changeInputMode(type) {
  try {
    // 要关闭软键盘的话，需要给inputmode属性设置none
    // 如果要打开软键盘的话，需要移出inputmode属性
    const el = inputAtRef.value
    if (!el) return console.warn('==== dom error ====')
    if (type == 'none') el.setAttribute('inputmode', 'none')
    if (type == 'remove') el.removeAttribute('inputmode')
  } catch (err) {
    console.warn('==== changeInputMode catch error :', err)
  }
}

/**
 * 阻止键盘弹出
 * @param {Function} callback 回调函数
 */
function noKeyboardEffect(callback) {
  // 以下严格处理异步与延时操作，缺一不可
  changeInputMode('none')
  callback()
  setTimeout(() => {
    changeInputMode('remove')
  })
}

/**
 * 阻止键盘弹出 ios专用
 * @param {Function} callback 回调函数
 */
function iosNoKeyboardEffect(callback) {
  // 可写模式，需要先关闭可写
  if (editable.value) {
    editable.value = false
    callback()
    setTimeout(() => {
      // 再恢复可写
      editable.value = true
    })
  } else {
    // 只读模式
    callback()
  }
}

defineExpose({
  initContent,
  getContent,
  clearContent,
  insertAt,
  insertAts,
  insertText,
  insertTextCmd,
  insertHtml,
  insertHtmlCmd,
  focus,
  blur,
  isBlur,
  getAllAtLink,
  noKeyboardEffect,
  iosNoKeyboardEffect,
  scrollToBottom,
  backspace
})
</script>

<style lang="scss">
.input-at {
  width: 100%;
  height: var(--at-height);
  min-height: var(--at-min-height);
  max-height: var(--at-max-height);
  border: 1px solid #cccccc;
  border-radius: 6px;
  padding: 4px 6px;
  box-sizing: border-box;
  overflow-y: auto;
  scroll-behavior: auto; /* auto 立即滚动 | smooth 平滑滚动 */
  white-space: pre-wrap; /* 保留换行符 */

  &.is-editor {
    a {
      text-decoration: none;
      color: #000000;
    }
  }

  &:empty {
    &::before {
      content: var(--at-placeholder-text);
      color: #999999;
    }
  }

  &:focus {
    outline: none;
    border-color: #007aff;
  }
}
</style>
